├── .gitignore ├── README.md ├── composer.json ├── config └── otp.php ├── src ├── Contracts │ ├── OtpBrokerInterface.php │ ├── OtpInterface.php │ └── OtpStoreInterface.php ├── Facades │ └── Otp.php ├── OtpBroker.php ├── OtpMakeCommand.php ├── OtpNotification.php ├── OtpServiceProvider.php └── OtpStore.php └── stubs └── otp.stub /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel OTP 2 | 3 | ## Introduction 4 | 5 | OTP Package for Laravel using class based system. Every Otp is a class that does something. For example, an `EmailVerificationOtp` which will mark the account as verified. 6 | 7 | ## Installation 8 | 9 | Install via composer 10 | 11 | ```bash 12 | composer require sadiqsalau/laravel-otp 13 | ``` 14 | 15 | Publish config file 16 | 17 | ```bash 18 | php artisan vendor:publish --provider="SadiqSalau\LaravelOtp\OtpServiceProvider" 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### Generate OTP 24 | 25 | ```bash 26 | php artisan make:otp {name} 27 | ``` 28 | 29 | A new Otp class will be generated into the `app/Otp` directory. e.g 30 | 31 | ```bash 32 | php artisan make:otp UserRegistrationOtp 33 | ``` 34 | 35 | Every Otp must implement the `process` method which will be called after verification. There the Otp can perform the necessary action and return any result. 36 | 37 | ```php 38 | $this->name, 74 | 'email' => $this->email, 75 | 'password' => Hash::make($this->password), 76 | 'email_verified_at' => now(), 77 | ]); 78 | }); 79 | 80 | event(new Registered($user)); 81 | 82 | Auth::login($user); 83 | 84 | return $user; 85 | } 86 | } 87 | 88 | ``` 89 | 90 | ### Sending OTP 91 | 92 | ```php 93 | send($otp, $notifiable); 97 | ``` 98 | 99 | - `$otp`: The otp to send. 100 | - `$notifiable`: AnonymousNotifiable or Notifiable instance. 101 | 102 | ```php 103 | use Illuminate\Support\Facades\Route; 104 | use Illuminate\Support\Facades\Notification; 105 | use Illuminate\Http\Request; 106 | use Illuminate\Validation\Rules; 107 | 108 | use SadiqSalau\LaravelOtp\Facades\Otp; 109 | 110 | use App\Models\User; 111 | use App\Otp\UserRegistrationOtp; 112 | 113 | Route::post('/register', function(Request $request){ 114 | $request->validate([ 115 | 'name' => ['required', 'string', 'max:255'], 116 | 'email' => ['required', 'string', 'email', 'max:255', 'unique:' . User::class], 117 | 'password' => ['required', Rules\Password::defaults()], 118 | ]); 119 | 120 | $otp = Otp::identifier($request->email)->send( 121 | new UserRegistrationOtp( 122 | name: $request->name, 123 | email: $request->email, 124 | password: $request->password 125 | ), 126 | Notification::route('mail', $request->email) 127 | ); 128 | 129 | return __($otp['status']); 130 | }); 131 | ``` 132 | 133 | Returns 134 | 135 | ```php 136 | ['status' => Otp::OTP_SENT] // Success: otp.sent 137 | ``` 138 | 139 | ### Verify OTP 140 | 141 | ```php 142 | attempt($code); 146 | ``` 147 | 148 | - `$code`: The otp code to compare against. 149 | 150 | Returns 151 | 152 | ```php 153 | ['status' => Otp::OTP_EMPTY] // Error: otp.empty 154 | ['status' => Otp::OTP_MISMATCHED] // Error: otp.mismatched 155 | ['status' => Otp::OTP_PROCESSED, 'result'=>[]] // Success: otp.processed 156 | ``` 157 | 158 | The `result` key contains the returned value of the `process` method of the Otp class 159 | 160 | ```php 161 | validate([ 170 | 'email' => ['required', 'string', 'email', 'max:255'], 171 | 'code' => ['required', 'string'] 172 | ]); 173 | 174 | $otp = Otp::identifier($request->email)->attempt($request->code); 175 | 176 | if($otp['status'] != Otp::OTP_PROCESSED) 177 | { 178 | abort(403, __($otp['status'])); 179 | } 180 | 181 | return $otp['result']; 182 | }); 183 | ``` 184 | 185 | ### Verify OTP without clearing from cache 186 | 187 | ```php 188 | check($code); 192 | ``` 193 | 194 | - `$code`: The otp code to compare against. 195 | 196 | Returns 197 | 198 | ```php 199 | ['status' => Otp::OTP_EMPTY] // Error: otp.empty 200 | ['status' => Otp::OTP_MISMATCHED] // Error: otp.mismatched 201 | ['status' => Otp::OTP_MATCHED] // Success: otp.matched 202 | ``` 203 | 204 | ### Resend OTP 205 | 206 | ```php 207 | update(); 211 | ``` 212 | 213 | Returns 214 | 215 | ```php 216 | ['status' => Otp::OTP_EMPTY] // Error: otp.empty 217 | ['status' => Otp::OTP_SENT] // Success: otp.sent 218 | ``` 219 | 220 | ```php 221 | validate([ 230 | 'email' => ['required', 'string', 'email', 'max:255'] 231 | ]); 232 | 233 | $otp = Otp::identifier($request->email)->update(); 234 | 235 | if($otp['status'] != Otp::OTP_SENT) 236 | { 237 | abort(403, __($otp['status'])); 238 | } 239 | return __($otp['status']); 240 | }); 241 | ``` 242 | 243 | ### Setting Identifier 244 | 245 | Every method of the OTP class requires setting an identifier to uniquely identify the Otp. 246 | 247 | ```php 248 | email)->send(...); 252 | ``` 253 | 254 | ```php 255 | send(...); 259 | Otp::identifier($identifier)->attempt(...); 260 | Otp::identifier($identifier)->update(); 261 | Otp::identifier($identifier)->check(...); 262 | ``` 263 | 264 | ## Config 265 | 266 | Config file can be found at `config/otp.php` after publishing the package 267 | 268 | - `format` - Format of generated OTP code (`numeric` | `alphanumeric` | `alpha`) 269 | - `length` - Length of generated OTP code 270 | - `expires` - Number of minutes before OTP expires, 271 | - `notification` - Custom notification class to use, default is `SadiqSalau\LaravelOtp\OtpNotification` 272 | 273 | ## Translations 274 | 275 | The package doesn't provide translations out of the box, but here is an example. 276 | Create a new translation file: `lang/en/otp.php` 277 | 278 | ```php 279 | 'We have sent your OTP code!', 293 | 'empty' => 'No OTP!', 294 | 'matched' => 'OTP code verified!', 295 | 'mismatched' => 'Mismatched OTP code!', 296 | 'processed' => 'OTP was successfully processed!' 297 | ]; 298 | 299 | ``` 300 | 301 | Then translate the status 302 | 303 | ```php 304 | return __($otp['status']) 305 | ``` 306 | 307 | ## API 308 | 309 | - `Otp::identifier(mixed $identifier)` - Set OTP identifier 310 | 311 | - `Otp::send(OtpInterface $otp, mixed $notifiable)` - Send OTP to a notifiable 312 | 313 | - `Otp::attempt(string $code)` - Attempt OTP code, returns the result of calling the `process` method of the OTP 314 | 315 | - `Otp::check(string $code)` - Compares the code against current OTP, this doesn't process or clear the OTP 316 | 317 | - `Otp::update()` - Resend and update current OTP 318 | 319 | - `Otp::clear()` - Remove OTP 320 | 321 | - `Otp::useGenerator(callable $callback)` - Set custom generator to use, generator will be called with `$format` and `$length` 322 | 323 | - `Otp::generateOtpCode($format, $length)` - Generates the OTP code 324 | 325 | ## Contribution 326 | 327 | Contributions are welcomed. 328 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sadiqsalau/laravel-otp", 3 | "description": "OTP Package for Laravel", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "SadiqSalau\\LaravelOtp\\": "src/" 9 | } 10 | }, 11 | "authors": [ 12 | { 13 | "name": "Sadiq Salau", 14 | "email": "sadiqsalau888@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.0", 19 | "laravel/framework": "^9|^10|^11|^12", 20 | "hi-folks/rando-php": "^0.2.0" 21 | }, 22 | "extra": { 23 | "laravel": { 24 | "providers": [ 25 | "SadiqSalau\\LaravelOtp\\OtpServiceProvider" 26 | ] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/otp.php: -------------------------------------------------------------------------------- 1 | env('OTP_FORMAT', 'numeric'), 13 | 14 | /* 15 | |-------------------------------------------------------------------------- 16 | | OTP characters length 17 | |-------------------------------------------------------------------------- 18 | | 19 | | Number of characters of OTP 20 | | 21 | */ 22 | 'length' => env('OTP_LENGTH', 6), 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | OTP expiration 27 | |-------------------------------------------------------------------------- 28 | | 29 | | Number of minutes before OTP expires 30 | | 31 | */ 32 | 'expires' => env('OTP_EXPIRES', 15), 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | OTP notification 37 | |-------------------------------------------------------------------------- 38 | | 39 | | Notification to use for OTP 40 | | 41 | */ 42 | 'notification' => \SadiqSalau\LaravelOtp\OtpNotification::class, 43 | ]; 44 | -------------------------------------------------------------------------------- /src/Contracts/OtpBrokerInterface.php: -------------------------------------------------------------------------------- 1 | createOtpData( 44 | $otp, 45 | $notifiable 46 | ); 47 | 48 | // Send notification 49 | $notifiable->notify( 50 | new $notification($data) 51 | ); 52 | 53 | // Store otp 54 | $this->store->put($data); 55 | 56 | return ['status' => static::OTP_SENT]; 57 | } 58 | 59 | /** 60 | * Update current Otp 61 | * 62 | * @return array 63 | */ 64 | public function update() 65 | { 66 | return ($data = $this->store->retrieve()) ? 67 | $this->send( 68 | $data['otp'], 69 | $data['notifiable'] 70 | ) : 71 | ['status' => static::OTP_EMPTY]; 72 | } 73 | 74 | /** 75 | * check Otp code without clearing 76 | * 77 | * @param string $code 78 | * @return array 79 | */ 80 | public function check($code) 81 | { 82 | // Otp exists? 83 | if (!$data = $this->store->retrieve()) 84 | return ['status' => static::OTP_EMPTY]; 85 | 86 | // Is the code correct? 87 | else if ($data['code'] != $code) 88 | return ['status' => static::OTP_MISMATCHED]; 89 | 90 | return [ 91 | 'status' => static::OTP_MATCHED, 92 | ]; 93 | } 94 | 95 | /** 96 | * Attempt Otp code and clear 97 | * 98 | * @param string $code 99 | * @return array 100 | */ 101 | public function attempt($code) 102 | { 103 | // Otp exists? 104 | if (!$data = $this->store->retrieve()) 105 | return ['status' => static::OTP_EMPTY]; 106 | 107 | // Is the code correct? 108 | else if ($data['code'] != $code) 109 | return ['status' => static::OTP_MISMATCHED]; 110 | 111 | // Process the Otp 112 | else { 113 | $result = $data['otp']->process(); 114 | 115 | // Clear the Otp 116 | $this->clear(); 117 | 118 | return [ 119 | 'status' => static::OTP_PROCESSED, 120 | 'result' => $result 121 | ]; 122 | } 123 | } 124 | 125 | /** 126 | * Clears Otp 127 | * 128 | * @return static 129 | */ 130 | public function clear() 131 | { 132 | $this->store->clear(); 133 | 134 | return $this; 135 | } 136 | 137 | /** 138 | * Create Otp data 139 | * 140 | * @param Otp $otp 141 | * @param mixed $notifiable 142 | * @return array 143 | */ 144 | protected function createOtpData( 145 | $otp, 146 | $notifiable 147 | ) { 148 | return [ 149 | 'otp' => $otp, 150 | 'notifiable' => $notifiable, 151 | 'code' => $this->generateOtpCode( 152 | config('otp.format'), 153 | config('otp.length') 154 | ), 155 | 'expires' => now()->addMinutes(config('otp.expires')) 156 | ]; 157 | } 158 | 159 | /** 160 | * Set store identifier 161 | * 162 | * @param string $identifier 163 | * @return static 164 | */ 165 | public function identifier($identifier) 166 | { 167 | $this->store->identifier($identifier); 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * Generates Otp code 174 | * 175 | * @param string $format 176 | * @param int $length 177 | * @return string 178 | */ 179 | public static function generateOtpCode( 180 | $format, 181 | $length 182 | ) { 183 | return static::$customGenerator ? call_user_func( 184 | static::$customGenerator, 185 | $format, 186 | $length 187 | ) : static::defaultGenerator($format, $length); 188 | } 189 | 190 | /** 191 | * Set custom Otp generator 192 | * 193 | * @param callable $callback 194 | * @return static 195 | */ 196 | public static function useGenerator($callback) 197 | { 198 | if (is_callable($callback)) { 199 | static::$customGenerator = $callback; 200 | } 201 | } 202 | 203 | 204 | /** 205 | * Otp default generator 206 | * 207 | * @param string $format 208 | * @param int $length 209 | * @return string 210 | * @throws \Exception 211 | */ 212 | protected static function defaultGenerator($format, $length) 213 | { 214 | if (!in_array($format, ['numeric', 'alpha', 'alphanumeric'], true)) { 215 | throw new \Exception('Unknown OTP code format!'); 216 | } 217 | return Randomize::chars($length)->{$format}()->generate(); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/OtpMakeCommand.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | public function via(object $notifiable): array 31 | { 32 | return ['mail']; 33 | } 34 | 35 | /** 36 | * Get the mail representation of the notification. 37 | */ 38 | public function toMail(object $notifiable): MailMessage 39 | { 40 | return (new MailMessage) 41 | ->subject('OTP Verification Code') 42 | ->line('Your OTP verification code is: ' . $this->data['code']) 43 | ->line('Code expires at ' . $this->data['expires']) 44 | ->line('Thank you for using our application!'); 45 | } 46 | 47 | /** 48 | * Get the array representation of the notification. 49 | * 50 | * @return array 51 | */ 52 | public function toArray(object $notifiable): array 53 | { 54 | return [ 55 | // 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/OtpServiceProvider.php: -------------------------------------------------------------------------------- 1 | configurationIsCached()) { 18 | $this->mergeConfigFrom( 19 | __DIR__ . '/../config/otp.php', 20 | 'otp' 21 | ); 22 | } 23 | 24 | /** Bind otp broker */ 25 | app()->bind('otp', OtpBroker::class); 26 | } 27 | 28 | /** 29 | * Bootstrap services. 30 | */ 31 | public function boot(): void 32 | { 33 | if (app()->runningInConsole()) { 34 | 35 | /** Publish config */ 36 | $this->publishes([ 37 | __DIR__ . '/../config/otp.php' => config_path('otp.php'), 38 | ], 'otp'); 39 | 40 | /** Register otp make command */ 41 | $this->commands([ 42 | OtpMakeCommand::class, 43 | ]); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/OtpStore.php: -------------------------------------------------------------------------------- 1 | identifier = md5($identifier); 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * Put Otp in cache 44 | * 45 | * @param array $otp 46 | * @return void 47 | */ 48 | public function put($otp) 49 | { 50 | Cache::put( 51 | $this->getCacheKey(), 52 | $otp, 53 | $otp['expires'] 54 | ); 55 | } 56 | 57 | /** 58 | * Get Otp in cache 59 | * 60 | * @return array|null 61 | */ 62 | public function retrieve() 63 | { 64 | return Cache::get($this->getCacheKey()) ?: null; 65 | } 66 | 67 | /** 68 | * Remove Otp from cache 69 | * 70 | * @return void 71 | */ 72 | public function clear() 73 | { 74 | Cache::forget($this->getCacheKey()); 75 | } 76 | 77 | /** 78 | * Return the cache key 79 | * 80 | * @return string 81 | * @throws \Exception 82 | */ 83 | protected function getCacheKey() 84 | { 85 | if (!isset($this->identifier)) { 86 | throw new \Exception("No OTP identifier set!"); 87 | } 88 | return static::STORE_KEY . '_' . $this->identifier; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /stubs/otp.stub: -------------------------------------------------------------------------------- 1 |