├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── magiclink.php ├── databases └── migrations │ ├── 2017_07_06_000000_create_table_magic_links.php │ ├── 2021_03_06_211907_add_access_code_to_magic_links_table.php │ └── 2024_12_25_000000_add_indexes_to_magic_links_table.php ├── resources └── views │ └── ask-for-access-code-form.blade.php ├── routes └── routes.php └── src ├── AccessCode.php ├── Actions ├── ActionAbstract.php ├── ControllerAction.php ├── DownloadFileAction.php ├── LoginAction.php ├── ResponseAction.php └── ViewAction.php ├── Controllers └── MagicLinkController.php ├── Events ├── MagicLinkWasCreated.php ├── MagicLinkWasDeleted.php └── MagicLinkWasVisited.php ├── MagicLink.php ├── MagicLinkServiceProvider.php ├── Middlewares └── MagiclinkMiddleware.php └── Responses ├── AbortResponse.php ├── RedirectResponse.php ├── Response.php ├── ResponseContract.php └── ViewResponse.php /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on 6 | [Github](https://github.com/cesargb/laravel-magiclink). 7 | 8 | 9 | ## Pull Requests 10 | 11 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 12 | 13 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 14 | 15 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 16 | 17 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 18 | 19 | - **Create feature branches** - Don't ask us to pull from your master branch. 20 | 21 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 22 | 23 | 24 | ## Running Tests 25 | 26 | ``` bash 27 | $ composer test 28 | ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Cesargb 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # MagicLink for Laravels App 3 | 4 | Through the `MagicLink` class we can create a secure link that later 5 | being visited will perform certain actions, which will allow us 6 | offer secure content and even log in to the application. 7 | 8 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/cesargb/laravel-magiclink.svg?style=flat-square)](https://packagist.org/packages/cesargb/laravel-magiclink) 9 | ![tests](https://github.com/cesargb/laravel-magiclink/workflows/tests/badge.svg) 10 | [![style-fix](https://github.com/cesargb/laravel-magiclink/actions/workflows/style-fix.yml/badge.svg)](https://github.com/cesargb/laravel-magiclink/actions/workflows/style-fix.yml) 11 | [![Quality Score](https://img.shields.io/scrutinizer/g/cesargb/laravel-magiclink.svg?style=flat-square)](https://scrutinizer-ci.com/g/cesargb/laravel-magiclink) 12 | [![Total Downloads](https://img.shields.io/packagist/dt/cesargb/laravel-magiclink.svg?style=flat-square)](https://packagist.org/packages/cesargb/laravel-magiclink) 13 | 14 | ## Contents 15 | 16 | - [Installation](#installation) 17 | - [Use case](#use-case) 18 | - [Create a MagicLink](#create-a-magiclink) 19 | - [Actions](#actions) 20 | - [Login](#login-action) 21 | - [Download file](#download-file-action) 22 | - [View](#view-action) 23 | - [Http Response](#http-response-action) 24 | - [Controller](#controller-action) 25 | - [Custom Action](#custom-action) 26 | - [Protect with an access code](#protect-with-an-access-code) 27 | - [Lifetime](#lifetime) 28 | - [Events](#events) 29 | - [Customization](#customization) 30 | - [Rate limiting](#rate-limiting) 31 | - [Testing](#testing) 32 | - [Contributing](#contributing) 33 | - [Security](#security) 34 | 35 | ## Installation 36 | 37 | You can install this package via composer using: 38 | 39 | ```bash 40 | composer require cesargb/laravel-magiclink 41 | ``` 42 | 43 | ### Preparing the database 44 | 45 | You need to publish the migration to create the `magic_links` table: 46 | 47 | ```bash 48 | php artisan vendor:publish --provider="MagicLink\MagicLinkServiceProvider" --tag="migrations" 49 | ``` 50 | 51 | After that, you need to run migrations. 52 | 53 | ```bash 54 | php artisan migrate 55 | ``` 56 | 57 | ## Use case 58 | 59 | With this example you can create a link to auto login on your application with 60 | the desired user: 61 | 62 | ```php 63 | use MagicLink\Actions\LoginAction; 64 | use MagicLink\MagicLink; 65 | 66 | $urlToAutoLogin = MagicLink::create(new LoginAction($user))->url 67 | ``` 68 | 69 | ## Create a MagicLink 70 | 71 | The `MagicLink` class has the `create` method to generate a class that through 72 | the `url` property we will obtain the link that we will send to our visitor. 73 | 74 | This method requires the action to be performed. 75 | 76 | ## Actions 77 | 78 | Each MagicLink is associated with an action, which is what will be performed 79 | once the link is visited. 80 | 81 | - [Login Action](#login-action) 82 | - [Download file Action](#download-file-action) 83 | - [View Action](#view-action) 84 | - [Http Response Action](#http-response-action) 85 | - [Http Response](#http-response-action) 86 | - [Controller](#controller-action) 87 | - [Custom Action](#custom-action) 88 | - [Custom Base URL](#custom-base-url) 89 | 90 | ### Login Action 91 | 92 | Through the `LoginAction` action, you can log in to the application using the 93 | generated link by `MagicLink`. 94 | 95 | Your constructor supports the user who will login. Optionally we can specify 96 | the [HTTP response](https://laravel.com/docs/master/responses) using the method 97 | `response` or specify other guard with method `guard`. 98 | 99 | Examples: 100 | 101 | ```php 102 | use MagicLink\Actions\LoginAction; 103 | use MagicLink\MagicLink; 104 | 105 | // Sample 1; Login and redirect to dash board 106 | $action = new LoginAction(User::first()); 107 | $action->response(redirect('/dashboard')); 108 | 109 | $urlToDashBoard = MagicLink::create($action)->url; 110 | 111 | // Sample 2; Login and view forms to password reset and use guard web 112 | $action = new LoginAction(User::first()); 113 | $action->response(view('password.reset', ['email' => 'user@example.tld'])); 114 | 115 | $urlShowView = MagicLink::create($action)->url; 116 | 117 | // Sample 3; Login in other guard and redirect default 118 | $action = new LoginAction(User::first()); 119 | $action->guard('customguard'); 120 | 121 | $urlShowView = MagicLink::create($action)->url; 122 | 123 | // Sample 4; Login and remember me 124 | $action = new LoginAction(User::first()); 125 | $action->remember(); 126 | 127 | $urlShowView = MagicLink::create($action)->url; 128 | ``` 129 | 130 | ### Download file Action 131 | 132 | This action, `DownloadFileAction`, permit create a link to download a private file. 133 | 134 | The constructor require the file path. 135 | 136 | Example: 137 | 138 | ```php 139 | use MagicLink\Actions\DownloadFileAction; 140 | use MagicLink\MagicLink; 141 | 142 | // Url to download the file storage_app('private_document.pdf') 143 | $url = MagicLink::create(new DownloadFileAction('private_document.pdf'))->url; 144 | 145 | // Download file with other file_name 146 | $action = new DownloadFileAction('private_document.pdf', 'your_document.pdf'); 147 | $url = MagicLink::create($action)->url; 148 | 149 | // Download file from other disk 150 | $action = new DownloadFileAction('private_document.pdf')->disk('ftp'); 151 | $url = MagicLink::create($action)->url; 152 | 153 | ``` 154 | 155 | ### View Action 156 | 157 | With the action `ViewAction`, you can provide access to the view. You can use 158 | in his constructor the same arguments than method `view` of Laravel. 159 | 160 | Example: 161 | 162 | ```php 163 | use MagicLink\Actions\ViewAction; 164 | use MagicLink\MagicLink; 165 | 166 | // Url to view a internal.blade.php 167 | $url = MagicLink::create(new ViewAction('internal', [ 168 | 'data' => 'Your private custom content', 169 | ]))->url; 170 | ``` 171 | 172 | ### Http Response Action 173 | 174 | Through the `ResponseAction` action we can access private content without need 175 | login. Its constructor accepts as argument the 176 | [HTTP response](https://laravel.com/docs/responses) 177 | which will be the response of the request. 178 | 179 | Examples: 180 | 181 | ```php 182 | use MagicLink\Actions\ResponseAction; 183 | use MagicLink\MagicLink; 184 | 185 | $action = new ResponseAction(function () { 186 | Auth::login(User::first()); 187 | 188 | return redirect('/change_password'); 189 | }); 190 | 191 | $urlToCustomFunction = MagicLink::create($action)->url; 192 | ``` 193 | 194 | ### Controller Action 195 | 196 | `MagicLink` can directly call a controller via the `ControllerAction` action. 197 | 198 | The constructor requires one argument, the name of the controller class. With 199 | the second argument can call any controller method, by default it will use the 200 | `__invoke` method. 201 | 202 | ```php 203 | use MagicLink\Actions\ControllerAction; 204 | use MagicLink\MagicLink; 205 | 206 | // Call the method __invoke of the controller 207 | $url = MagicLink::create(new ControllerAction(MyController::class))->url; 208 | 209 | // Call the method show of the controller 210 | $url = MagicLink::create(new ControllerAction(MyController::class, 'show'))->url; 211 | ``` 212 | 213 | ### Custom Action 214 | 215 | You can create your own action class, for them you just need to extend with 216 | `MagicLink\Actions\ActionAbstract` 217 | 218 | ```php 219 | use MagicLink\Actions\ActionAbstract; 220 | 221 | class MyCustomAction extends ActionAbstract 222 | { 223 | public function __construct(public int $variable) 224 | { 225 | } 226 | 227 | public function run() 228 | { 229 | // Do something 230 | 231 | return response()->json([ 232 | 'success' => true, 233 | 'data' => $this->variable, 234 | ]); 235 | } 236 | } 237 | ``` 238 | 239 | You can now generate a Magiclink with the custom action 240 | 241 | ```php 242 | use MagicLink\MagicLink; 243 | 244 | $action = new MyCustomAction('Hello world'); 245 | 246 | $urlToCustomAction = MagicLink::create($action)->url; 247 | ``` 248 | 249 | ### Custom Base URL 250 | 251 | To set the base URL for a magic link, you can use the `baseUrl` method. This method ensures that the provided base URL has a trailing slash, making it ready for URL generation. 252 | 253 | ```php 254 | $magiclink = MagicLink::create($action); 255 | 256 | $magiclink->baseUrl("http://example.com"); 257 | 258 | $urlShowView = $magiclink->url; // http://example.com/magiclink/... 259 | ``` 260 | 261 | ## Protect with an access code 262 | 263 | Optionally you can protect the resources with an access code. 264 | You can set the access code with method `protectWithAccessCode` 265 | which accepts an argument with the access code. 266 | 267 | ```php 268 | $magiclink = MagicLink::create(new DownloadFileAction('private_document.pdf')); 269 | 270 | $magiclink->protectWithAccessCode('secret'); 271 | 272 | $urlToSend = $magiclink->url; 273 | ``` 274 | 275 | ### Custom view for access code 276 | 277 | You can customize the view of the access code form with the config file `magiclink.php`: 278 | 279 | ```php 280 | 'access_code' => [ 281 | 'view' => 'magiclink::access-code', // Change with your view 282 | ], 283 | ``` 284 | 285 | This is the [default view](/resources/views/ask-for-access-code-form.blade.php) 286 | 287 | ## Lifetime 288 | 289 | By default a link will be available for 72 hours after your creation. We can 290 | modify the life time in minutes of the link by the `$lifetime` option 291 | available in the `create` method. This argument accepts the value `null` so 292 | that it does not expire in time. 293 | 294 | ```php 295 | $lifetime = 60; // 60 minutes 296 | 297 | $magiclink = MagicLink::create(new ResponseAction(), $lifetime); 298 | 299 | $urlToSend = $magiclink->url; 300 | ``` 301 | 302 | We also have another option `$numMaxVisits`, with which we can define the 303 | number of times the link can be visited, `null` by default indicates that there 304 | are no visit limits. 305 | 306 | ```php 307 | $lifetime = null; // not expired in the time 308 | $numMaxVisits = 1; // Only can visit one time 309 | 310 | $magiclink = MagicLink::create(new ResponseAction(), $lifetime, $numMaxVisits); 311 | 312 | $urlToSend = $magiclink->url; 313 | ``` 314 | 315 | ## Events 316 | 317 | MagicLink can fires three events: 318 | 319 | ### MagicLinkWasCreated 320 | 321 | Event `MagicLink\Events\MagicLinkWasCreated` 322 | 323 | This event is fired when a magic link is created. 324 | 325 | ### MagicLinkWasVisited 326 | 327 | Event `MagicLink\Events\MagicLinkWasVisited` 328 | 329 | This event is fired when a magic link is visited. 330 | 331 | ### MagicLinkWasDeleted 332 | 333 | Event `MagicLink\Events\MagicLinkWasDeleted` 334 | 335 | This event is fired when you disable mass deletion. Add this line in your 336 | `.env` file to disable mass deletion: 337 | 338 | ```.env 339 | # Disable mass deletion for enable event MagicLinkWasDeleted 340 | MAGICLINK_DELETE_MASSIVE=false 341 | ``` 342 | 343 | > [!WARNING] 344 | > If you disable mass deletion, the cleanup will be performed one by one. 345 | > If you have many records, this can be an issue. 346 | 347 | ## Customization 348 | 349 | ### Config 350 | 351 | To custom this package you can publish the config file: 352 | 353 | ```bash 354 | php artisan vendor:publish --provider="MagicLink\MagicLinkServiceProvider" --tag="config" 355 | ``` 356 | 357 | And edit the file `config/magiclink.php` 358 | 359 | ### Migrations 360 | 361 | To customize the migration files of this package you need to publish the migration files: 362 | 363 | ```bash 364 | php artisan vendor:publish --provider="MagicLink\MagicLinkServiceProvider" --tag="migrations" 365 | ``` 366 | 367 | You'll find the published files in `database/migrations/*` 368 | 369 | ### Custom response when magiclink is invalid 370 | 371 | When the magicLink is invalid by default the http request return a status 403. 372 | You can custom this response with config `magiclink.invalid_response`. 373 | 374 | #### Response 375 | 376 | To return a response, use class `MagicLink\Responses\Response::class` 377 | same `response()`, you can send the arguments with options 378 | 379 | Example: 380 | 381 | ```php 382 | 'invalid_response' => [ 383 | 'class' => MagicLink\Responses\Response::class, 384 | 'options' => [ 385 | 'content' => 'forbidden', 386 | 'status' => 403, 387 | ], 388 | ], 389 | ``` 390 | 391 | #### Abort 392 | 393 | To return a exception and let the framework handle the response, 394 | use class `MagicLink\Responses\AbortResponse::class`. 395 | Same `abort()`, you can send the arguments with options. 396 | 397 | Example: 398 | 399 | ```php 400 | 'invalid_response' => [ 401 | 'class' => MagicLink\Responses\AbortResponse::class, 402 | 'options' => [ 403 | 'message' => 'You Shall Not Pass!', 404 | 'status' => 403, 405 | ], 406 | ], 407 | ``` 408 | 409 | #### Redirect 410 | 411 | Define class `MagicLink\Responses\RedirectResponse::class` to 412 | return a `redirect()` 413 | 414 | ```php 415 | 'invalid_response' => [ 416 | 'class' => MagicLink\Responses\RedirectResponse::class, 417 | 'options' => [ 418 | 'to' => '/not_valid_path', 419 | 'status' => 301, 420 | ], 421 | ], 422 | ``` 423 | 424 | #### View 425 | 426 | Define class `MagicLink\Responses\ViewResponse::class` to 427 | return a `view()` 428 | 429 | ```php 430 | 'invalid_response' => [ 431 | 'class' => MagicLink\Responses\ViewResponse::class, 432 | 'options' => [ 433 | 'view' => 'invalid', 434 | 'data' => [], 435 | ], 436 | ], 437 | ``` 438 | 439 | ## Rate limiting 440 | 441 | You can limit the number of requests per minute for a magic link. To do this, you need to 442 | set the `MAGICLINK_RATE_LIMIT` environment variable to the desired value. 443 | 444 | By default, the rate limit is disable with value 'none', but you can set a value 445 | to limit the requests. For example, to limit the requests to 100 per minute, set 446 | 447 | ```bash 448 | # .env 449 | 450 | MAGICLINK_RATE_LIMIT=100 451 | ``` 452 | 453 | ## Testing 454 | 455 | Run the tests with: 456 | 457 | ``` bash 458 | composer test 459 | ``` 460 | 461 | ## Contributing 462 | 463 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 464 | 465 | ## Security 466 | 467 | If you discover any security-related issues, please email cesargb@gmail.com 468 | instead of using the issue tracker. 469 | 470 | ## License 471 | 472 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 473 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cesargb/laravel-magiclink", 3 | "description": "Create secure link for access to private data or login in Laravel without password", 4 | "keywords": [ 5 | "laravel", 6 | "auth", 7 | "login", 8 | "link", 9 | "download", 10 | "file" 11 | ], 12 | "homepage": "https://github.com/cesargb/laravel-magiclink", 13 | "authors": [ 14 | { 15 | "name": "Cesar Garcia", 16 | "email": "cesargb@gmail.com", 17 | "homepage": "https://github.com/cesargb", 18 | "role": "Developer" 19 | } 20 | ], 21 | "license": "MIT", 22 | "require": { 23 | "php": "^8.1", 24 | "illuminate/auth": "^10.0|^11.0|^12.0", 25 | "illuminate/container": "^10.0|^11.0|^12.0", 26 | "illuminate/contracts": "^10.0|^11.0|^12.0", 27 | "illuminate/database": "^10.0|^11.0|^12.0", 28 | "laravel/serializable-closure": "^1.0|^2.0", 29 | "orchestra/testbench": "^8.0|^9.0|^10.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "MagicLink\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "MagicLink\\Test\\": "tests" 39 | } 40 | }, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit" 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "MagicLink\\MagicLinkServiceProvider" 48 | ] 49 | } 50 | }, 51 | "require-dev": { 52 | "phpunit/phpunit": "^9.0|^10.5|^11.5", 53 | "friendsofphp/php-cs-fixer": "^3.9" 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true 57 | } 58 | -------------------------------------------------------------------------------- /config/magiclink.php: -------------------------------------------------------------------------------- 1 | [ 7 | /* 8 | |-------------------------------------------------------------------------- 9 | | Access Code View 10 | |-------------------------------------------------------------------------- 11 | | 12 | | Here you may specify the view to ask for access code. 13 | | 14 | */ 15 | 'view' => 'magiclink::ask-for-access-code-form', 16 | ], 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Delete Magic Link Expired massive 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Expired MagicLinks are automatically and massively deleted from the database. 24 | | If you want to disable this option, change the value to false. 25 | | 26 | | If you disable this option, expired MagicLinks will be deleted one by one 27 | | triggering the event MagicLink\Events\MagicLinkWasDeleted. 28 | | 29 | */ 30 | 'delete_massive' => env('MAGICLINK_DELETE_MASSIVE', true), 31 | 32 | /* 33 | |-------------------------------------------------------------------------- 34 | | Disable default route 35 | |-------------------------------------------------------------------------- 36 | | 37 | | If you wish use your custom controller, you can invalidate the 38 | | default route of magic link, mark this configuration as true, 39 | | and add your custom route with the middleware: 40 | | MagicLink\Middlewares\MagiclinkMiddleware 41 | | 42 | */ 43 | 'disable_default_route' => false, 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Response when token is invalid 48 | |-------------------------------------------------------------------------- 49 | | 50 | | Here you may specify the class with method __invoke to get the response 51 | | when token is invalid 52 | | 53 | */ 54 | 'invalid_response' => [ 55 | 'class' => MagicLink\Responses\Response::class, 56 | ], 57 | 58 | 'middlewares' => [ 59 | 'throttle:magiclink', 60 | MagicLink\Middlewares\MagiclinkMiddleware::class, 61 | 'web', 62 | ], 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Rate Limit 67 | |-------------------------------------------------------------------------- 68 | | 69 | | Here you may specify the number of attempts to rate limit per minutes 70 | | 71 | | Default: none, if you want to enable rate limit, set as integer 72 | */ 73 | 'rate_limit' => env('MAGICLINK_RATE_LIMIT', 'none'), 74 | 75 | 'token' => [ 76 | /* 77 | |-------------------------------------------------------------------------- 78 | | Token size 79 | |-------------------------------------------------------------------------- 80 | | 81 | | Here you may specify the length of token to verify the identify. 82 | | Max value is 255 characters, it will be used if bigger value is set. 83 | | 84 | */ 85 | 'length' => 64, 86 | ], 87 | 88 | 'url' => [ 89 | /* 90 | |-------------------------------------------------------------------------- 91 | | Path default to redirect 92 | |-------------------------------------------------------------------------- 93 | | 94 | | Here you may specify the name of the path you'd like to use so that 95 | | the redirect when verify correct token. 96 | | 97 | */ 98 | 'redirect_default' => '/', 99 | 100 | /* 101 | |-------------------------------------------------------------------------- 102 | | Path to Validate Token and Auto Auth 103 | |-------------------------------------------------------------------------- 104 | | 105 | | Here you may specify the name of the path you'd like to use so that 106 | | the verify token and auth in system. 107 | | 108 | */ 109 | 'validate_path' => 'magiclink', 110 | ], 111 | 112 | ]; 113 | -------------------------------------------------------------------------------- /databases/migrations/2017_07_06_000000_create_table_magic_links.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 18 | $table->string('token', 255); 19 | $table->text('action'); 20 | $table->unsignedTinyInteger('num_visits')->default(0); 21 | $table->unsignedTinyInteger('max_visits')->nullable(); 22 | $table->timestamp('available_at')->nullable(); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists(config('magiclink.magiclink_table', 'magic_links')); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /databases/migrations/2021_03_06_211907_add_access_code_to_magic_links_table.php: -------------------------------------------------------------------------------- 1 | string('access_code')->nullable(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | if (Schema::hasColumn('magic_links', 'access_code')) { 29 | Schema::table('magic_links', function (Blueprint $table) { 30 | $table->dropColumn('access_code'); 31 | }); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /databases/migrations/2024_12_25_000000_add_indexes_to_magic_links_table.php: -------------------------------------------------------------------------------- 1 | index('available_at'); 18 | $table->index('max_visits'); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::table('magic_links', function (Blueprint $table) { 30 | $table->dropIndex('magic_links_available_at_index'); 31 | $table->dropIndex('magic_links_max_visits_index'); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /resources/views/ask-for-access-code-form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Password protected 5 | 6 | 7 | 8 | 50 | 51 | 52 |
53 |
54 |
Enter access code
55 | 56 |
57 | {{ csrf_field() }} 58 | 59 |
60 | 61 | 62 | @if (Request::get('access-code')) 63 |
Access code is wrong
64 | @else 65 |
And press enter
66 | @endif 67 |
68 | 69 | 70 | 71 |
72 |
73 |
74 | 75 | 76 | -------------------------------------------------------------------------------- /routes/routes.php: -------------------------------------------------------------------------------- 1 | config('magiclink.middlewares'), 8 | ], 9 | function () { 10 | Route::get( 11 | config('magiclink.url.validate_path', 'magiclink').'/{token}', 12 | 'MagicLink\Controllers\MagicLinkController@access' 13 | ); 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /src/AccessCode.php: -------------------------------------------------------------------------------- 1 | getResponseAccessCodeFromForm(); 20 | 21 | return $responseFromForm 22 | ? $responseFromForm 23 | : $this->getResponseAccessCodeFromCookie(); 24 | } 25 | 26 | /** 27 | * Check if access code is right. 28 | */ 29 | private function checkAccessCode(?string $accessCode): bool 30 | { 31 | if ($accessCode === null) { 32 | return false; 33 | } 34 | 35 | return Hash::check($accessCode, $this->getAccessCode()); 36 | } 37 | 38 | /** 39 | * The action was protected with an access code. 40 | */ 41 | private function protectedWithAccessCode(): bool 42 | { 43 | return ! is_null($this->getAccessCode() ?? null); 44 | } 45 | 46 | private function getResponseAccessCodeFromForm() 47 | { 48 | $accessCode = $this->getAccessCodeFromForm(); 49 | 50 | if ( 51 | $this->protectedWithAccessCode() 52 | && $accessCode 53 | && $this->checkAccessCode($accessCode) 54 | ) { 55 | return redirect(request()->url())->withCookie( 56 | cookie( 57 | $this->cookieName, 58 | encrypt($this->getMagikLinkId().'|'.$accessCode), 59 | 0, 60 | '/' 61 | ) 62 | ); 63 | } 64 | 65 | return null; 66 | } 67 | 68 | private function getResponseAccessCodeFromCookie() 69 | { 70 | if ($this->protectedWithAccessCode()) { 71 | if ($this->getAccessCodeFromCookie()) { 72 | if ($this->checkAccessCode($this->getAccessCodeFromCookie())) { 73 | return null; 74 | } 75 | } 76 | 77 | return response()->view( 78 | config('magiclink.access-code.view', 'magiclink::ask-for-access-code-form'), 79 | [], 80 | 403 81 | ); 82 | } 83 | 84 | return null; 85 | } 86 | 87 | private function getAccessCodeFromForm() 88 | { 89 | return request()->get('access-code'); 90 | } 91 | 92 | private function getAccessCodeFromCookie() 93 | { 94 | $accessCodeCookies = request()->cookie($this->cookieName); 95 | 96 | if (! $accessCodeCookies) { 97 | return null; 98 | } 99 | 100 | try { 101 | $cookie = Arr::last((array) $accessCodeCookies); 102 | 103 | [$magiclinkId, $accessCode] = explode('|', decrypt($cookie)); 104 | 105 | if ($magiclinkId === $this->getMagikLinkId()) { 106 | return $accessCode; 107 | } 108 | } catch (DecryptException $e) { 109 | } 110 | 111 | return null; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Actions/ActionAbstract.php: -------------------------------------------------------------------------------- 1 | magiclinkId = $magiclinkId; 17 | 18 | return $this; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Actions/ControllerAction.php: -------------------------------------------------------------------------------- 1 | controllerClass = $controllerClass; 13 | 14 | $this->method = $method; 15 | } 16 | 17 | /** 18 | * Execute Action. 19 | */ 20 | public function run() 21 | { 22 | $controller = app()->make($this->controllerClass); 23 | 24 | if ($this->method) { 25 | return $controller->{$this->method}(); 26 | } 27 | 28 | return $controller(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Actions/DownloadFileAction.php: -------------------------------------------------------------------------------- 1 | path = $path; 25 | if (! is_null($name)) { 26 | $this->name($name); 27 | } 28 | $this->headers($headers); 29 | } 30 | 31 | public function name(string $name): self 32 | { 33 | $this->name = $name; 34 | 35 | return $this; 36 | } 37 | 38 | public function disk(?string $disk): self 39 | { 40 | $this->disk = $disk; 41 | 42 | return $this; 43 | } 44 | 45 | public function headers(array $headers): self 46 | { 47 | $this->headers = $headers; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Execute Action. 54 | */ 55 | public function run() 56 | { 57 | return Storage::disk($this->disk)->download($this->path, $this->name, $this->headers); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Actions/LoginAction.php: -------------------------------------------------------------------------------- 1 | authIdentifier = $user->getAuthIdentifier(); 24 | 25 | $this->response($httpResponse); 26 | 27 | $this->guard = $guard; 28 | } 29 | 30 | public function remember(bool $remember = true): self 31 | { 32 | $this->remember = $remember; 33 | 34 | return $this; 35 | } 36 | 37 | public function guard(string $guard): self 38 | { 39 | $this->guard = $guard; 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * Execute Action. 46 | */ 47 | public function run() 48 | { 49 | Auth::guard($this->guard)->loginUsingId($this->authIdentifier, $this->remember); 50 | 51 | return parent::run(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Actions/ResponseAction.php: -------------------------------------------------------------------------------- 1 | response($httpResponse); 23 | } 24 | 25 | public function response($response): self 26 | { 27 | $this->httpResponse = $this->serializeResponse($response); 28 | 29 | return $this; 30 | } 31 | 32 | public function redirect($response): self 33 | { 34 | $this->httpResponse = $this->serializeResponse($response); 35 | 36 | return $this; 37 | } 38 | 39 | protected function serializeResponse($httpResponse) 40 | { 41 | return serialize($this->formattedResponse($httpResponse)); 42 | } 43 | 44 | protected function formattedResponse($response) 45 | { 46 | if (is_null($response)) { 47 | return new RedirectResponse( 48 | config('magiclink.url.redirect_default', '/'), 49 | 302 50 | ); 51 | } 52 | 53 | if ($response instanceof RedirectResponse) { 54 | return new RedirectResponse( 55 | $response->getTargetUrl(), 56 | $response->getStatusCode() 57 | ); 58 | } 59 | 60 | if (is_callable($response)) { 61 | return new SerializableClosure(Closure::fromCallable($response)); 62 | } 63 | 64 | if ($response instanceof View) { 65 | return $response->render(); 66 | } 67 | 68 | return $response; 69 | } 70 | 71 | /** 72 | * Execute Action. 73 | */ 74 | public function run() 75 | { 76 | return $this->callResponse(unserialize($this->httpResponse)); 77 | } 78 | 79 | protected function callResponse($httpResponse) 80 | { 81 | if (is_callable($httpResponse)) { 82 | return $httpResponse(MagicLink::find($this->magiclinkId)); 83 | } 84 | 85 | return $httpResponse; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Actions/ViewAction.php: -------------------------------------------------------------------------------- 1 | view = $view; 22 | 23 | $this->data = $data; 24 | 25 | $this->mergeData = $mergeData; 26 | } 27 | 28 | /** 29 | * Execute Action. 30 | */ 31 | public function run() 32 | { 33 | return view($this->view, $this->data, $this->mergeData); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Controllers/MagicLinkController.php: -------------------------------------------------------------------------------- 1 | run(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Events/MagicLinkWasCreated.php: -------------------------------------------------------------------------------- 1 | magiclink = $magiclink; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Events/MagicLinkWasDeleted.php: -------------------------------------------------------------------------------- 1 | magiclink = $magiclink; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Events/MagicLinkWasVisited.php: -------------------------------------------------------------------------------- 1 | magiclink = $magiclink; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/MagicLink.php: -------------------------------------------------------------------------------- 1 | access_code ?? null; 32 | } 33 | 34 | public function getMagikLinkId() 35 | { 36 | return $this->getKey(); 37 | } 38 | 39 | public $incrementing = false; 40 | 41 | protected $keyType = 'string'; 42 | 43 | protected static function boot() 44 | { 45 | parent::boot(); 46 | 47 | static::creating(function ($model) { 48 | $model->id = Str::uuid(); 49 | }); 50 | } 51 | 52 | protected static function getTokenLength() 53 | { 54 | return config('magiclink.token.length', 64) <= 255 55 | ? config('magiclink.token.length', 64) 56 | : 255; 57 | } 58 | 59 | public function getActionAttribute($value) 60 | { 61 | if ($this->getConnection()->getDriverName() === 'pgsql') { 62 | return unserialize(base64_decode($value)); 63 | } 64 | 65 | return unserialize($value); 66 | } 67 | 68 | public function setActionAttribute($value) 69 | { 70 | $this->attributes['action'] = $this->getConnection()->getDriverName() === 'pgsql' 71 | ? base64_encode(serialize($value)) 72 | : serialize($value); 73 | } 74 | 75 | public function baseUrl(?string $baseUrl): self 76 | { 77 | $this->attributes['base_url'] = rtrim($baseUrl, '/') . '/'; 78 | 79 | return $this; 80 | } 81 | 82 | public function getUrlAttribute(): string 83 | { 84 | $baseUrl = rtrim($this->attributes['base_url'] ?? '', '/') . '/'; // Use the stored base_url or an empty string 85 | 86 | return url(sprintf( 87 | '%s%s/%s%s%s', 88 | $baseUrl, 89 | config('magiclink.url.validate_path', 'magiclink'), 90 | $this->id, 91 | urlencode(':'), 92 | $this->token 93 | )); 94 | } 95 | 96 | /** 97 | * Create MagicLink. 98 | * 99 | * @return self 100 | */ 101 | public static function create(ActionAbstract $action, ?int $lifetime = 4320, ?int $numMaxVisits = null) 102 | { 103 | static::deleteMagicLinkExpired(); 104 | 105 | $magiclink = new static(); 106 | 107 | $magiclink->token = Str::random(static::getTokenLength()); 108 | $magiclink->available_at = $lifetime 109 | ? Carbon::now()->addMinutes($lifetime) 110 | : null; 111 | $magiclink->max_visits = $numMaxVisits; 112 | $magiclink->action = $action; 113 | 114 | $magiclink->save(); 115 | 116 | $magiclink->action = $action->setMagicLinkId($magiclink->id); 117 | 118 | $magiclink->save(); 119 | 120 | Event::dispatch(new MagicLinkWasCreated($magiclink)); 121 | 122 | return $magiclink; 123 | } 124 | 125 | /** 126 | * Protect the Action with an access code. 127 | */ 128 | public function protectWithAccessCode(string $accessCode): self 129 | { 130 | $this->access_code = Hash::make($accessCode); 131 | 132 | $this->save(); 133 | 134 | return $this; 135 | } 136 | 137 | /** 138 | * Execute Action. 139 | * 140 | * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse 141 | */ 142 | public function run() 143 | { 144 | return $this->action->run(); 145 | } 146 | 147 | /** 148 | * Call when magiclink has been visited. 149 | * 150 | * @return void 151 | */ 152 | public function visited() 153 | { 154 | try { 155 | $this->increment('num_visits'); 156 | } catch (QueryException $e) { 157 | // catch exceptino if fails to increment num_visits 158 | } 159 | 160 | Event::dispatch(new MagicLinkWasVisited($this)); 161 | } 162 | 163 | /** 164 | * Get valid MagicLink by token. 165 | * 166 | * @param string $token 167 | * @return \MagicLink\MagicLink|null 168 | */ 169 | public static function getValidMagicLinkByToken($token) 170 | { 171 | [$tokenId, $tokenSecret] = explode(':', "{$token}:"); 172 | 173 | if (empty($tokenSecret)) { 174 | return null; 175 | } 176 | 177 | return static::where('id', $tokenId) 178 | ->where('token', $tokenSecret) 179 | ->where(function ($query) { 180 | $query 181 | ->whereNull('available_at') 182 | ->orWhere('available_at', '>=', Carbon::now()); 183 | }) 184 | ->where(function ($query) { 185 | $query 186 | ->whereNull('max_visits') 187 | ->orWhereRaw('max_visits > num_visits'); 188 | }) 189 | ->first(); 190 | } 191 | 192 | /** 193 | * Get MagicLink by token. 194 | * 195 | * @param string $token 196 | * @return \MagicLink\MagicLink|null 197 | */ 198 | public static function getMagicLinkByToken($token) 199 | { 200 | [$tokenId, $tokenSecret] = explode(':', "{$token}:"); 201 | 202 | if (empty($tokenSecret)) { 203 | return null; 204 | } 205 | 206 | return static::where('id', $tokenId) 207 | ->where('token', $tokenSecret) 208 | ->first(); 209 | } 210 | 211 | /** 212 | * Delete MagicLink was expired. 213 | * 214 | * @return void 215 | */ 216 | public static function deleteMagicLinkExpired() 217 | { 218 | $query = MagicLink::where(function ($query) { 219 | $query 220 | ->where('available_at', '<', Carbon::now()) 221 | ->orWhere(function ($query) { 222 | $query 223 | ->whereNotNull('max_visits') 224 | ->whereRaw('max_visits <= num_visits'); 225 | }); 226 | }); 227 | 228 | if (config('magiclink.delete_massive', true)) { 229 | $query->delete(); 230 | 231 | return; 232 | } 233 | 234 | 235 | $query->get()->each(function (MagicLink $magiclink) { 236 | $magiclink->delete(); 237 | 238 | event(new MagicLinkWasDeleted($magiclink)); 239 | }); 240 | } 241 | 242 | /** 243 | * Delete all MagicLink. 244 | * 245 | * @return void 246 | */ 247 | public static function deleteAllMagicLink() 248 | { 249 | static::truncate(); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/MagicLinkServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/magiclink.php', 'magiclink'); 14 | } 15 | 16 | public function boot() 17 | { 18 | $this->offerPublishing(); 19 | 20 | $this->registerRateLimit(); 21 | 22 | $this->loadRouteMagicLink(); 23 | 24 | $this->loadViewMagicLink(); 25 | } 26 | 27 | private function registerRateLimit(): void 28 | { 29 | $rateLimit = config('magiclink.rate_limit', 'none'); 30 | 31 | RateLimiter::for( 32 | 'magiclink', 33 | fn () => $rateLimit === 'none' 34 | ? Limit::none() 35 | : Limit::perMinute($rateLimit) 36 | ); 37 | } 38 | 39 | private function loadRouteMagicLink(): void 40 | { 41 | $disableRegisterRoute = config('magiclink.disable_default_route', false); 42 | 43 | if ($disableRegisterRoute) { 44 | return; 45 | } 46 | 47 | $this->loadRoutesFrom(__DIR__.'/../routes/routes.php'); 48 | } 49 | 50 | private function loadViewMagicLink(): void 51 | { 52 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'magiclink'); 53 | } 54 | 55 | private function offerPublishing(): void 56 | { 57 | if (! $this->app->runningInConsole()) { 58 | return; 59 | } 60 | 61 | $this->publishes([ 62 | __DIR__.'/../config/magiclink.php' => config_path('magiclink.php'), 63 | ], 'config'); 64 | 65 | $this->publishes([ 66 | __DIR__.'/../databases/migrations' => database_path('migrations'), 67 | ], 'migrations'); 68 | 69 | $this->publishes([ 70 | __DIR__.'/../resources/views' => resource_path('views/vendor/magiclink'), 71 | ], 'views'); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Middlewares/MagiclinkMiddleware.php: -------------------------------------------------------------------------------- 1 | route('token'); 15 | 16 | $magicLink = MagicLink::getValidMagicLinkByToken($token); 17 | 18 | if ($request->method() === 'HEAD') { 19 | return response()->noContent($magicLink ? 200 : 404); 20 | } 21 | 22 | if (! $magicLink) { 23 | return $this->badResponse(); 24 | } 25 | 26 | $responseAccessCode = $magicLink->getResponseAccessCode(); 27 | 28 | if ($responseAccessCode) { 29 | return $responseAccessCode; 30 | } 31 | 32 | $magicLink->visited(); 33 | 34 | return $next($request); 35 | } 36 | 37 | protected function badResponse() 38 | { 39 | $responseClass = config('magiclink.invalid_response.class', Response::class); 40 | 41 | $response = new $responseClass(); 42 | 43 | return $response(config('magiclink.invalid_response.options', [])); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Responses/AbortResponse.php: -------------------------------------------------------------------------------- 1 | view( 10 | $options['view'] ?? null, 11 | $options['data'] ?? [] 12 | ); 13 | } 14 | } 15 | --------------------------------------------------------------------------------