├── pint.json ├── CHANGELOG.md ├── loginlink.png ├── .gitignore ├── src ├── Controllers │ └── PasswordlessController.php ├── CanUsePasswordlessAuthenticatable.php ├── Traits │ ├── PasswordlessAuthenticatable.php │ └── PasswordlessAuth.php ├── TokenInterface.php ├── Facades │ └── Passwordless.php ├── PasswordlessManager.php ├── Notifications │ └── SendMagicLinkNotification.php ├── PasswordlessServiceProvider.php ├── TokenRepository.php └── MagicLink.php ├── routes └── routes.php ├── phpstan.neon.dist ├── tests ├── Fixtures │ ├── routes.php │ ├── TestController.php │ └── Models │ │ └── User.php ├── Unit │ ├── UserUnitTest.php │ ├── MagicLinkUnitTest.php │ └── TokenRepositoryUnitTest.php ├── TestCase.php └── Feature │ ├── LoginFeatureTest.php │ └── MagicLinkFeatureTest.php ├── .github ├── workflows │ ├── pint.yml │ ├── phpstan.yml │ ├── update-changelog.yml │ └── run-tests.yml └── dependabot.yml ├── phpunit.xml.dist ├── database └── migrations │ └── create_passwordless_auth_table.php ├── composer.json ├── config └── passwordless.php └── README.md /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "exclude": [ 4 | "build" 5 | ] 6 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. -------------------------------------------------------------------------------- /loginlink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/norbybaru/laravel-passwordless-authentication/HEAD/loginlink.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.vscode 3 | /*.cache 4 | /build 5 | /composer.lock 6 | /vendor 7 | phpunit.xml 8 | phpunit.xml.dist.bak -------------------------------------------------------------------------------- /src/Controllers/PasswordlessController.php: -------------------------------------------------------------------------------- 1 | middleware(['web', 'signed']) 8 | ->name('passwordless.login'); 9 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | - config 9 | 10 | # The level 9 is the highest level 11 | level: 5 12 | 13 | tmpDir: build/phpstan 14 | checkModelProperties: true 15 | checkMissingIterableValueType: true -------------------------------------------------------------------------------- /tests/Fixtures/routes.php: -------------------------------------------------------------------------------- 1 | name('home'); 7 | Route::get('/login', [TestController::class, 'index'])->middleware(['guest'])->name('login'); 8 | Route::get('/intended-redirect', [TestController::class, 'redirect'])->middleware(['auth'])->name('redirect'); 9 | -------------------------------------------------------------------------------- /src/CanUsePasswordlessAuthenticatable.php: -------------------------------------------------------------------------------- 1 | name, 200); 13 | } 14 | 15 | public function redirect() 16 | { 17 | return response('Redirected '.Auth::user()->name, 200); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/pint.yml: -------------------------------------------------------------------------------- 1 | name: Laravel Pint 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.php' 7 | 8 | jobs: 9 | pint: 10 | name: pint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: '8.0' 19 | coverage: none 20 | 21 | - name: Install composer dependencies 22 | uses: ramsey/composer-install@v3 23 | 24 | - name: Run pint 25 | run: ./vendor/bin/pint --test -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: PHPStan 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.php' 7 | - 'phpstan.neon.dist' 8 | 9 | 10 | jobs: 11 | phpstan: 12 | name: phpstan 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: '8.0' 21 | coverage: none 22 | 23 | - name: Install composer dependencies 24 | uses: ramsey/composer-install@v3 25 | 26 | - name: Run PHPStan 27 | run: ./vendor/bin/phpstan --error-format=github -------------------------------------------------------------------------------- /src/Traits/PasswordlessAuthenticatable.php: -------------------------------------------------------------------------------- 1 | email; 15 | } 16 | 17 | /** 18 | * Send Magic link to user to login. 19 | */ 20 | public function sendAuthenticationMagicLink(string $token): void 21 | { 22 | $this->notify(new SendMagicLinkNotification($token)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/TokenInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /database/migrations/create_passwordless_auth_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('email')->index(); 19 | $table->string('token')->unique(); 20 | $table->timestamp('created_at')->nullable(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('passwordless_auth'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/User.php: -------------------------------------------------------------------------------- 1 | user = User::create([ 20 | 'name' => $this->faker->name, 21 | 'email' => $this->faker->unique()->safeEmail, 22 | 'email_verified_at' => now(), 23 | 'password' => Hash::make(Str::random(10)), 24 | 'remember_token' => Str::random(10), 25 | ]); 26 | } 27 | 28 | public function test_user_model_should_implement_passwordless() 29 | { 30 | $this->assertInstanceOf(CanUsePasswordlessAuthenticatable::class, $this->user); 31 | } 32 | 33 | public function test_it_should_return_email_for_magic_link() 34 | { 35 | $this->assertNotNull($this->user->getEmailForMagicLink()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Facades/Passwordless.php: -------------------------------------------------------------------------------- 1 | createTokenRepository(), 18 | $this->app['auth']->createUserProvider('users') 19 | ); 20 | } 21 | 22 | protected function createTokenRepository(): TokenRepository 23 | { 24 | $key = $this->app['config']['app.key']; 25 | 26 | if (Str::startsWith($key, 'base64:')) { 27 | $key = base64_decode(substr($key, 7)); 28 | } 29 | 30 | $config = $this->getPasswordlessConfig(); 31 | 32 | return new TokenRepository( 33 | $this->app['db']->connection(), 34 | $config['table'], 35 | $key, 36 | $config['expire'], 37 | $config['throttle'] 38 | ); 39 | } 40 | 41 | protected function getAuthProvider(): mixed 42 | { 43 | return $this->app['config']['auth.providers.users']; 44 | } 45 | 46 | protected function getPasswordlessConfig(): array 47 | { 48 | return $this->app['config']->get('passwordless'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "norbybaru/passwordless-auth", 3 | "description": "Laravel Passwordless login - Magic link", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": ["laravel", "passwordless", "magic link", "laravel auth"], 7 | "authors": [ 8 | { 9 | "name": "Norby Baruani", 10 | "email": "norbybaru@gmail.com" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { 15 | "NorbyBaru\\Passwordless\\": "src/" 16 | } 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "NorbyBaru\\Passwordless\\Tests\\": "tests/" 21 | } 22 | }, 23 | "extra": { 24 | "laravel": { 25 | "providers": [ 26 | "NorbyBaru\\Passwordless\\PasswordlessServiceProvider" 27 | ], 28 | "aliases": { 29 | "Passwordless": "NorbyBaru\\Passwordless\\Facades\\Passwordless" 30 | } 31 | } 32 | }, 33 | "config": { 34 | "sort-packages": true 35 | }, 36 | "scripts": { 37 | "analyse": "vendor/bin/phpstan analyse", 38 | "fmt": "./vendor/bin/pint -v", 39 | "post-autoload-dump": [ 40 | "@php ./vendor/bin/testbench package:discover --ansi" 41 | ], 42 | "test": "phpunit" 43 | }, 44 | "require": { 45 | "php": "^8.0", 46 | "illuminate/support": "^9.52|^10.0|^11.0" 47 | }, 48 | "prefer-stable": true, 49 | "require-dev": { 50 | "laravel/pint": "^1.1", 51 | "nunomaduro/larastan": "^1.0|^2.0|^3.0", 52 | "orchestra/testbench": "^7.0|^8.0|^9.0", 53 | "phpunit/phpunit": "^9.5|^10.0|^11.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Notifications/SendMagicLinkNotification.php: -------------------------------------------------------------------------------- 1 | subject(Lang::get('Sign in to :app_name', ['app_name' => env('APP_NAME', 'Laravel')])) 35 | ->line(Lang::get('Click the link below to sign in to your account.')) 36 | ->line(Lang::get('This link will expire in :count minutes and can only be used once.', ['count' => config('passwordless.magic_link_timeout')])) 37 | ->action(Lang::get('Sign In to :app_name', ['app_name' => env('APP_NAME', 'Laravel')]), $this->verificationUrl($notifiable)) 38 | ->line(Lang::get('If you did not make this request, no further action is required.')); 39 | } 40 | 41 | /** 42 | * Get the verification URL for the given notifiable. 43 | */ 44 | protected function verificationUrl($notifiable): string 45 | { 46 | return Passwordless::magicLink()->generateUrl($notifiable, $this->token); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/PasswordlessServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishConfig(); 12 | $this->publishDatabase(); 13 | $this->loadRoutes(); 14 | } 15 | 16 | public function register() 17 | { 18 | $this->mergeConfigFrom($this->configPath(), 'passwordless'); 19 | $this->registerPasswordlessManager(); 20 | } 21 | 22 | protected function registerPasswordlessManager() 23 | { 24 | $this->app->singleton('auth.passwordless', function () { 25 | return new PasswordlessManager($this->app); 26 | }); 27 | } 28 | 29 | protected function loadRoutes() 30 | { 31 | $this->loadRoutesFrom(__DIR__.'/../routes/routes.php'); 32 | } 33 | 34 | /** 35 | * Return config file 36 | */ 37 | protected function configPath(): string 38 | { 39 | return __DIR__.'/../config/passwordless.php'; 40 | } 41 | 42 | /** 43 | * Publish config file. 44 | */ 45 | protected function publishConfig() 46 | { 47 | $this->publishes([ 48 | $this->configPath() => config_path('passwordless.php'), 49 | ], 'passwordless-config'); 50 | } 51 | 52 | protected function publishDatabase() 53 | { 54 | $this->publishes([ 55 | __DIR__.'/../database/migrations/create_passwordless_auth_table.php' => database_path('migrations/'.date('Y_m_d_His', time()).'_create_passwordless_auth_table.php'), 56 | ], 'passwordless-migrations'); 57 | } 58 | 59 | /** 60 | * Get the services provided by the provider. 61 | */ 62 | public function provides(): array 63 | { 64 | return ['auth.passwordless']; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | php: [8.0, 8.1, 8.2, 8.3] 19 | laravel: [9.*, 10.*, 11.*] 20 | dependency-version: [prefer-lowest, prefer-stable] 21 | exclude: 22 | - laravel: 9.* 23 | php: 8.3 24 | - laravel: 10.* 25 | php: 8.0 26 | - laravel: 11.* 27 | php: 8.0 28 | - laravel: 11.* 29 | php: 8.1 30 | 31 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 32 | 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Setup PHP 38 | uses: shivammathur/setup-php@v2 39 | with: 40 | php-version: ${{matrix.php}} 41 | 42 | - name: Get Composer cache directory 43 | id: composer-cache 44 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 45 | 46 | - name: Cache Composer dependencies 47 | uses: actions/cache@v4 48 | with: 49 | path: ${{ steps.composer-cache.outputs.dir }} 50 | # Use composer.json for key, if composer.lock is not committed. 51 | key: php-${{ matrix.php }}-lara-${{ matrix.laravel }}-composer-${{ matrix.dependency-version }}-${{ hashFiles('**/composer.json') }} 52 | # key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 53 | restore-keys: php-${{ matrix.php }}-lara-${{ matrix.laravel }}-composer-${{ matrix.dependency-version }}- 54 | 55 | - name: Install Composer dependencies 56 | run: | 57 | composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update 58 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 59 | 60 | - name: Run PHPUnit tests 61 | run: vendor/bin/phpunit --testdox -------------------------------------------------------------------------------- /tests/Unit/MagicLinkUnitTest.php: -------------------------------------------------------------------------------- 1 | magicLink = Passwordless::magicLink(); 24 | 25 | $this->user = UserModel::create([ 26 | 'name' => $this->faker->name, 27 | 'email' => $this->faker->unique()->safeEmail, 28 | 'email_verified_at' => now(), 29 | 'password' => Hash::make(Str::random(10)), 30 | 'remember_token' => Str::random(10), 31 | ]); 32 | } 33 | 34 | public function test_it_should_create_a_token() 35 | { 36 | $token = $this->magicLink->createToken($this->user); 37 | $this->assertNotEmpty($token); 38 | } 39 | 40 | public function test_it_should_created_signed_url() 41 | { 42 | $signedUrl = $this->magicLink->generateUrl($this->user, Str::random(40)); 43 | 44 | $queryParams = collect(explode('&', substr($signedUrl, strpos($signedUrl, '?') + 1))) 45 | ->mapWithKeys(function ($value) { 46 | $values = explode('=', $value); 47 | 48 | return [$values[0] => $values[1]]; 49 | }); 50 | $url = Arr::first(explode('?', $signedUrl)); 51 | 52 | $this->assertNotNull($queryParams['hash']); 53 | $this->assertNotNull($queryParams['signature']); 54 | $this->assertNotNull($queryParams['expires']); 55 | $this->assertEquals($this->user->email, urldecode($queryParams['email'])); 56 | $this->assertEquals(config('app.url').config('passwordless.callback_url'), $url); 57 | $this->assertNotNull($url); 58 | $this->assertNotEmpty($url); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | setUpFaker(); 26 | Notification::fake(); 27 | 28 | $this->user = UserModel::create([ 29 | 'name' => $this->faker->name, 30 | 'email' => $this->faker->unique()->safeEmail, 31 | 'email_verified_at' => now(), 32 | 'password' => Hash::make(Str::random(10)), 33 | 'remember_token' => Str::random(10), 34 | ]); 35 | } 36 | 37 | /** 38 | * @param \Illuminate\Foundation\Application $app 39 | */ 40 | public function getEnvironmentSetUp($app) 41 | { 42 | parent::getEnvironmentSetUp($app); 43 | } 44 | 45 | protected function defineRoutes($router) 46 | { 47 | require __DIR__.'/Fixtures/routes.php'; 48 | } 49 | 50 | /** 51 | * Define environment setup. 52 | * 53 | * @param \Illuminate\Foundation\Application $app 54 | * @return void 55 | */ 56 | protected function defineEnvironment($app) 57 | { 58 | $app['config']->set('auth.providers.users.model', User::class); 59 | } 60 | 61 | /** 62 | * Define database migrations. 63 | * 64 | * @return void 65 | */ 66 | protected function defineDatabaseMigrations() 67 | { 68 | $this->loadMigrationsFrom(base_path('migrations')); 69 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 70 | } 71 | 72 | /** 73 | * Get package providers. 74 | * 75 | * @param \Illuminate\Foundation\Application $app 76 | */ 77 | protected function getPackageProviders($app): array 78 | { 79 | return [ 80 | PasswordlessServiceProvider::class, 81 | ]; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /config/passwordless.php: -------------------------------------------------------------------------------- 1 | '/callback/login', 14 | 15 | /* 16 | |-------------------------------------------------------------------------- 17 | | Default route name for redirect 18 | |-------------------------------------------------------------------------- 19 | | 20 | | Set default public route name to redirect authenticated user when successfully authenticated or failure to authenticate. 21 | | This route name will only be apply when no intended url has been stored in the session to redirect user 22 | | when trying to access auth page and no 'redirect_to' query params is found on the url 23 | | 24 | */ 25 | 'default_redirect_route' => 'home', 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Login route name 30 | |-------------------------------------------------------------------------- 31 | | 32 | | Set current login route name of your application to give the package ability to redirect to the page 33 | | whenever an issue occurred with magic link validation 34 | | 35 | */ 36 | 'login_route' => 'login', 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Table Name 41 | |-------------------------------------------------------------------------- 42 | | 43 | | 44 | */ 45 | 'table' => 'passwordless_auth', 46 | 47 | /* 48 | |-------------------------------------------------------------------------- 49 | | Token Expiry time 50 | |-------------------------------------------------------------------------- 51 | | 52 | | The expire time is the number of seconds that token should be 53 | | considered valid. This security feature keeps tokens short-lived so 54 | | they have less time to be guessed. You may change this as needed. 55 | | 56 | */ 57 | 'expire' => 60 * 60, 58 | 59 | /* 60 | |-------------------------------------------------------------------------- 61 | | Throttle 62 | |-------------------------------------------------------------------------- 63 | | 64 | | Amount of seconds to wait before generating and sending a new magic link to User. 65 | | Throttling is mechanism to prevent spamming user and exhausting system resource 66 | | 67 | */ 68 | 'throttle' => 60, 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Magic Link Time out 73 | |-------------------------------------------------------------------------- 74 | | 75 | | The expire time is the number of seconds that the magic link signature should be 76 | | considered valid. This security feature keeps signature short-lived so 77 | | they have less time to be guessed. You may change this as needed. 78 | | 79 | */ 80 | 'magic_link_timeout' => 60 * 60, 81 | ]; 82 | -------------------------------------------------------------------------------- /tests/Feature/LoginFeatureTest.php: -------------------------------------------------------------------------------- 1 | createToken($this->user); 20 | $this->signedUrl = Passwordless::magicLink()->generateUrl($this->user, $token); 21 | } 22 | 23 | public function test_it_should_successfully_login_user() 24 | { 25 | $this->assertGuest(); 26 | $response = $this->followingRedirects()->get($this->signedUrl); 27 | $response->assertSuccessful(); 28 | $this->assertAuthenticatedAs($this->user); 29 | } 30 | 31 | public function test_tempered_signed_url_should_not_successfully_login_user() 32 | { 33 | $this->withoutExceptionHandling(); 34 | $this->assertGuest(); 35 | $this->expectException(InvalidSignatureException::class); 36 | $this->get($this->signedUrl.'.tempered'); 37 | $this->assertGuest(); 38 | } 39 | 40 | public function test_expired_signed_url_should_not_successfully_login_user() 41 | { 42 | $this->withoutExceptionHandling(); 43 | $knowTime = Carbon::now()->addSeconds(config('passwordless.magic_link_timeout') + 1); 44 | Carbon::setTestNow($knowTime); 45 | $this->assertGuest(); 46 | $this->expectException(InvalidSignatureException::class); 47 | $this->get($this->signedUrl); 48 | $this->assertGuest(); 49 | } 50 | 51 | public function test_successful_login_should_follow_intended_url() 52 | { 53 | $this->assertGuest(); 54 | $response = $this->get('/intended-redirect'); 55 | $response->assertStatus(302); 56 | $response = $this->get($this->signedUrl); 57 | $response->assertRedirect('/intended-redirect'); 58 | $this->assertAuthenticatedAs($this->user); 59 | } 60 | 61 | public function test_expired_token_should_not_successfully_login_user() 62 | { 63 | $this->withoutExceptionHandling(); 64 | $this->assertGuest(); 65 | $knowTime = Carbon::now()->addSeconds(config('passwordless.expire') + 1); 66 | Carbon::setTestNow($knowTime); 67 | $this->assertGuest(); 68 | $this->expectException(InvalidSignatureException::class); 69 | $this->get($this->signedUrl); 70 | $this->assertGuest(); 71 | } 72 | 73 | public function test_tempered_email_should_not_successfully_login_user() 74 | { 75 | $this->withoutExceptionHandling(); 76 | $this->assertGuest(); 77 | $queryParams = collect(explode('&', substr($this->signedUrl, strpos($this->signedUrl, '?') + 1))) 78 | ->mapWithKeys(function ($value) { 79 | $values = explode('=', $value); 80 | 81 | return [$values[0] => $values[1]]; 82 | }); 83 | $url = Arr::first(explode('?', $this->signedUrl)); 84 | $queryParams['email'] = $this->faker->email; 85 | $temperedUrl = $url.'?'.http_build_query($queryParams->all()); 86 | $this->expectException(InvalidSignatureException::class); 87 | $this->get($temperedUrl); 88 | $this->assertGuest(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Traits/PasswordlessAuth.php: -------------------------------------------------------------------------------- 1 | verifyMagicLink($request); 25 | 26 | if (! $response instanceof CanUsePasswordlessAuthenticatable) { 27 | if ($request->wantsJson()) { 28 | throw ValidationException::withMessages([ 29 | 'email' => [trans($response)], 30 | ]); 31 | } 32 | 33 | return redirect()->to($this->redirectRoute($request, false)) 34 | ->withInput($request->only('email')) 35 | ->withErrors(['email' => trans($response)]); 36 | } 37 | 38 | $this->authenticateUser($response); 39 | 40 | if ($response = $this->authenticatedResponse($request, auth()->user())) { 41 | return $response; 42 | } 43 | 44 | return $request->wantsJson() 45 | ? new JsonResponse([], 204) 46 | : redirect()->intended($this->redirectRoute($request)); 47 | } 48 | 49 | protected function redirectRoute(Request $request, bool $success = true): string 50 | { 51 | if ($request->get('redirect_to')) { 52 | return $request->get('redirect_to'); 53 | } 54 | 55 | if (method_exists($this, 'redirectTo')) { 56 | return $this->redirectTo(); 57 | } 58 | 59 | $routeName = config('passwordless.default_redirect_route'); 60 | 61 | if (! $success) { 62 | $routeName = config('passwordless.login_route'); 63 | } 64 | 65 | return route($routeName); 66 | } 67 | 68 | protected function verifyMagicLink(Request $request): string|Authenticatable|CanUsePasswordlessAuthenticatable 69 | { 70 | $request->validate($this->requestRules()); 71 | 72 | $user = $this->magicLink()->validateMagicLink($this->requestCredentials($request)); 73 | 74 | if (! $user instanceof CanUsePasswordlessAuthenticatable) { 75 | return $user; 76 | } 77 | 78 | if (! hash_equals((string) $this->requestCredentials($request)['hash'], sha1($user->getEmailForMagicLink()))) { 79 | throw new AuthorizationException; 80 | } 81 | 82 | return $user; 83 | } 84 | 85 | public function authenticateUser($user) 86 | { 87 | auth()->login($user); 88 | } 89 | 90 | /** 91 | * The user has been authenticated. 92 | * 93 | * @return RedirectResponse|Response|null 94 | */ 95 | public function authenticatedResponse(Request $request, $user) 96 | { 97 | return null; 98 | } 99 | 100 | protected function requestRules(): array 101 | { 102 | return [ 103 | 'token' => 'required', 104 | 'email' => 'required|email', 105 | 'hash' => 'required', 106 | ]; 107 | } 108 | 109 | protected function requestCredentials(Request $request): array 110 | { 111 | return $request->only(['email', 'token', 'hash']); 112 | } 113 | 114 | public function magicLink(): MagicLink 115 | { 116 | return Passwordless::magicLink(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/TokenRepository.php: -------------------------------------------------------------------------------- 1 | recentlyCreatedToken($user)) { 28 | return null; 29 | } 30 | 31 | $this->delete($user); 32 | 33 | $token = $this->generateToken(); 34 | 35 | $this->getPasswordlessTable()->insert([ 36 | 'email' => $user->getEmailForMagicLink(), 37 | 'token' => Hash::make($token), 38 | 'created_at' => Carbon::now(), 39 | ]); 40 | 41 | return $token; 42 | } 43 | 44 | /** 45 | * Determine if the given user recently created a password reset token. 46 | */ 47 | public function recentlyCreatedToken(CanUsePasswordlessAuthenticatable $user): bool 48 | { 49 | $record = $this->getPasswordlessTable() 50 | ->where('email', $user->getEmailForMagicLink()) 51 | ->first(); 52 | 53 | if (! $record) { 54 | return false; 55 | } 56 | 57 | return $this->tokenWasRecentlyCreated($record->created_at); 58 | } 59 | 60 | /** 61 | * Check if was recently created based on throttle 62 | */ 63 | private function tokenWasRecentlyCreated(string $createdAt): bool 64 | { 65 | if ($this->throttle <= 0) { 66 | return false; 67 | } 68 | 69 | return Carbon::parse($createdAt) 70 | ->addSeconds($this->throttle) 71 | ->isFuture(); 72 | } 73 | 74 | /** 75 | * Token exits and valid 76 | */ 77 | public function exist(CanUsePasswordlessAuthenticatable $user, string $token): bool 78 | { 79 | $result = $this->getPasswordlessTable() 80 | ->where('email', $user->getEmailForMagicLink()) 81 | ->first(); 82 | 83 | if (! $result) { 84 | return false; 85 | } 86 | 87 | return ! $this->tokenExpired($result->created_at) && Hash::check($token, $result->token); 88 | } 89 | 90 | /** 91 | * Determine if the token has expired. 92 | */ 93 | protected function tokenExpired(string $createdAt): bool 94 | { 95 | return Carbon::parse($createdAt)->addSeconds($this->expires)->isPast(); 96 | } 97 | 98 | public function delete(CanUsePasswordlessAuthenticatable $user): bool 99 | { 100 | return (bool) $this->getPasswordlessTable() 101 | ->where('email', $user->getEmailForMagicLink()) 102 | ->delete(); 103 | } 104 | 105 | public function deleteExpired(): bool 106 | { 107 | $expiredAt = Carbon::now()->addSeconds($this->expires); 108 | 109 | return (bool) $this->getPasswordlessTable() 110 | ->where('created_at', '<=', $expiredAt) 111 | ->delete(); 112 | } 113 | 114 | protected function generateToken(): string 115 | { 116 | return hash_hmac('sha256', Str::random(40), $this->hashKey); 117 | } 118 | 119 | protected function getConnection(): ConnectionInterface 120 | { 121 | return $this->connection; 122 | } 123 | 124 | protected function getPasswordlessTable(): Builder 125 | { 126 | return $this->connection->table($this->table); 127 | } 128 | 129 | protected function getTable(): string 130 | { 131 | return $this->table; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/MagicLink.php: -------------------------------------------------------------------------------- 1 | addSeconds(config('passwordless.magic_link_timeout')), 59 | [ 60 | 'email' => $notifiable->getEmailForMagicLink(), 61 | 'hash' => sha1($notifiable->getEmailForMagicLink()), 62 | 'token' => $token, 63 | ], 64 | ); 65 | } 66 | 67 | public function sendLink(array $credentials): string 68 | { 69 | $user = $this->findUser($credentials); 70 | 71 | if (! $user) { 72 | return static::INVALID_USER; 73 | } 74 | 75 | if (! $token = $this->createToken($user)) { 76 | return static::TOKEN_THROTTLED; 77 | } 78 | 79 | $user->sendAuthenticationMagicLink($token); 80 | 81 | return static::MAGIC_LINK_SENT; 82 | } 83 | 84 | public function validateMagicLink(array $credentials): string|CanUsePasswordlessAuthenticatable|Authenticatable 85 | { 86 | $user = $this->findUser($credentials); 87 | 88 | if (! $user) { 89 | return static::INVALID_USER; 90 | } 91 | 92 | if (! $this->isValidToken($user, $credentials['token'])) { 93 | return static::INVALID_TOKEN; 94 | } 95 | 96 | $this->clearUserTokens($user); 97 | 98 | return $user; 99 | } 100 | 101 | public function isValidToken(CanUsePasswordlessAuthenticatable $user, string $token): bool 102 | { 103 | if ($this->token->exist($user, $token)) { 104 | return true; 105 | } 106 | 107 | return false; 108 | } 109 | 110 | public function createToken(CanUsePasswordlessAuthenticatable $user): ?string 111 | { 112 | return $this->token->create($user); 113 | } 114 | 115 | private function findUser(array $credentials): bool|CanUsePasswordlessAuthenticatable|Authenticatable 116 | { 117 | $credentials = Arr::except($credentials, ['token', 'hash']); 118 | $user = $this->user->retrieveByCredentials($credentials); 119 | 120 | if (! $user) { 121 | return false; 122 | } 123 | 124 | if (! $user instanceof CanUsePasswordlessAuthenticatable) { 125 | throw new UnexpectedValueException('User must implement CanUsePasswordlessAuthentication interface.'); 126 | } 127 | 128 | return $user; 129 | } 130 | 131 | private function clearUserTokens(CanUsePasswordlessAuthenticatable $user): bool 132 | { 133 | return $this->token->delete($user); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/Feature/MagicLinkFeatureTest.php: -------------------------------------------------------------------------------- 1 | createToken($this->user); 20 | 21 | $this->assertNotNull($token); 22 | } 23 | 24 | public function test_it_should_validate_token_successfully() 25 | { 26 | $token = Passwordless::magicLink()->createToken($this->user); 27 | $isValid = Passwordless::magicLink()->isValidToken($this->user, $token); 28 | 29 | $this->assertTrue($isValid); 30 | } 31 | 32 | public function test_it_should_unsuccessfully_validate_invalid_token() 33 | { 34 | Passwordless::magicLink()->createToken($this->user); 35 | $isValid = Passwordless::magicLink()->isValidToken($this->user, Str::random(24)); 36 | 37 | $this->assertFalse($isValid); 38 | } 39 | 40 | public function test_it_should_unsuccessfully_validate_expired_token() 41 | { 42 | $knowTime = Carbon::now()->addSeconds(config('passwordless.expire') + 1); 43 | Carbon::setTestNow(); 44 | $token = Passwordless::magicLink()->createToken($this->user); 45 | Carbon::setTestNow($knowTime); 46 | 47 | $isValid = Passwordless::magicLink()->isValidToken($this->user, $token); 48 | $this->assertFalse($isValid); 49 | } 50 | 51 | public function test_it_should_generate_temporary_signed_url() 52 | { 53 | $token = Passwordless::magicLink()->createToken($this->user); 54 | $signedUrl = Passwordless::magicLink()->generateUrl($this->user, $token); 55 | 56 | $queryParams = collect(explode('&', substr($signedUrl, strpos($signedUrl, '?') + 1))) 57 | ->mapWithKeys(function ($value) { 58 | $values = explode('=', $value); 59 | 60 | return [$values[0] => $values[1]]; 61 | }); 62 | $url = Arr::first(explode('?', $signedUrl)); 63 | 64 | $this->assertNotNull($queryParams['hash']); 65 | $this->assertNotNull($queryParams['signature']); 66 | $this->assertNotNull($queryParams['expires']); 67 | $this->assertEquals($this->user->email, urldecode($queryParams['email'])); 68 | $this->assertEquals(config('app.url').config('passwordless.callback_url'), $url); 69 | $this->assertNotNull($url); 70 | } 71 | 72 | public function test_it_should_send_login_link_to_user() 73 | { 74 | $status = Passwordless::magicLink()->sendLink(['email' => $this->user->email]); 75 | 76 | Notification::assertSentTo($this->user, SendMagicLinkNotification::class); 77 | 78 | $this->assertEquals(MagicLink::MAGIC_LINK_SENT, $status); 79 | } 80 | 81 | public function test_it_should_throttle_magic_link_generation_subsequent_request() 82 | { 83 | $knowTime = Carbon::now()->addSeconds(config('passwordless.throttle') - 1); 84 | 85 | Carbon::setTestNow(); 86 | $status = Passwordless::magicLink()->sendLink(['email' => $this->user->email]); 87 | Notification::assertSentTo($this->user, SendMagicLinkNotification::class); 88 | $this->assertEquals(MagicLink::MAGIC_LINK_SENT, $status); 89 | 90 | Notification::fake(); 91 | Carbon::setTestNow($knowTime); 92 | $status = Passwordless::magicLink()->sendLink(['email' => $this->user->email]); 93 | Notification::assertNotSentTo($this->user, SendMagicLinkNotification::class); 94 | $this->assertEquals(MagicLink::TOKEN_THROTTLED, $status); 95 | } 96 | 97 | public function test_it_should_not_generate_return_message_when_invalid_user_found() 98 | { 99 | $status = Passwordless::magicLink()->sendLink(['email' => $this->faker->email]); 100 | $this->assertEquals(MagicLink::INVALID_USER, $status); 101 | } 102 | 103 | public function test_it_should_successfully_validate_magic_link() 104 | { 105 | $token = Passwordless::magicLink()->createToken($this->user); 106 | $signedUrl = Passwordless::magicLink()->generateUrl($this->user, $token); 107 | $queryParams = collect(explode('&', substr($signedUrl, strpos($signedUrl, '?') + 1))) 108 | ->mapWithKeys(function ($value) { 109 | $values = explode('=', $value); 110 | 111 | return [$values[0] => $values[1]]; 112 | }); 113 | 114 | $credentials = [ 115 | 'token' => $queryParams->get('token'), 116 | 'email' => urldecode($queryParams->get('email')), 117 | ]; 118 | 119 | $result = Passwordless::magicLink()->validateMagicLink($credentials); 120 | 121 | $this->assertInstanceOf(UserModel::class, $result); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/Unit/TokenRepositoryUnitTest.php: -------------------------------------------------------------------------------- 1 | tokenRepository = $this->getTokenRepository(); 23 | 24 | $this->user = UserModel::create([ 25 | 'name' => $this->faker->name, 26 | 'email' => $this->faker->unique()->safeEmail, 27 | 'email_verified_at' => now(), 28 | 'password' => Hash::make(Str::random(10)), 29 | 'remember_token' => Str::random(10), 30 | ]); 31 | } 32 | 33 | public function test_it_should_create_new_token_for_user() 34 | { 35 | Carbon::setTestNow(); 36 | $token = $this->tokenRepository->create($this->user); 37 | $this->assertNotNull($token); 38 | 39 | $this->assertDatabaseHas(config('passwordless.table'), [ 40 | 'email' => $this->user->getEmailForMagicLink(), 41 | 'created_at' => now(), 42 | ]); 43 | } 44 | 45 | public function test_it_should_not_create_new_user_token_due_to_throttling() 46 | { 47 | $knowTime = Carbon::now()->addSeconds(config('passwordless.throttle') - 1); 48 | Carbon::setTestNow(); 49 | $token = $this->tokenRepository->create($this->user); 50 | $this->assertNotNull($token); 51 | 52 | Carbon::setTestNow($knowTime); 53 | $token = $this->tokenRepository->create($this->user); 54 | $this->assertNull($token); 55 | } 56 | 57 | public function test_it_should_determine_whether_token_was_recently_created_or_not() 58 | { 59 | Carbon::setTestNow(); 60 | $recent = $this->tokenRepository->recentlyCreatedToken($this->user); 61 | $this->assertFalse($recent); 62 | 63 | $this->tokenRepository->create($this->user); 64 | $recent = $this->tokenRepository->recentlyCreatedToken($this->user); 65 | $this->assertTrue($recent); 66 | } 67 | 68 | public function test_it_should_validate_that_valid_token_exist_and_is_valid() 69 | { 70 | $token = $this->tokenRepository->create($this->user); 71 | 72 | $isValid = $this->tokenRepository->exist($this->user, $token); 73 | $this->assertTrue($isValid); 74 | } 75 | 76 | public function test_it_should_validate_that_invalid_token_do_not_exist_and_is_invalid() 77 | { 78 | $isValid = $this->tokenRepository->exist($this->user, Str::random(40)); 79 | $this->assertFalse($isValid); 80 | } 81 | 82 | public function test_it_should_validate_that_expired_token_is_invalid() 83 | { 84 | $knowTime = Carbon::now()->subSeconds(config('passwordless.expire')); 85 | Carbon::setTestNow($knowTime); 86 | $token = $this->tokenRepository->create($this->user); 87 | 88 | $knowTime = Carbon::now()->addSeconds(config('passwordless.expire') + 1); 89 | Carbon::setTestNow($knowTime); 90 | $isValid = $this->tokenRepository->exist($this->user, $token); 91 | $this->assertFalse($isValid); 92 | } 93 | 94 | public function test_it_should_delete_all_user_tokens() 95 | { 96 | Carbon::setTestNow(); 97 | $this->tokenRepository->create($this->user); 98 | 99 | $this->assertDatabaseHas(config('passwordless.table'), [ 100 | 'email' => $this->user->getEmailForMagicLink(), 101 | ]); 102 | 103 | $this->tokenRepository->delete($this->user); 104 | 105 | $this->assertDatabaseMissing(config('passwordless.table'), [ 106 | 'email' => $this->user->getEmailForMagicLink(), 107 | ]); 108 | } 109 | 110 | public function test_it_should_delete_all_user_expired_tokens() 111 | { 112 | $knowTime = Carbon::now()->subSeconds(config('passwordless.expire')); 113 | Carbon::setTestNow($knowTime); 114 | $this->tokenRepository->create($this->user); 115 | $this->assertDatabaseCount(config('passwordless.table'), 1); 116 | 117 | $knowTime = Carbon::now(); 118 | Carbon::setTestNow($knowTime); 119 | $this->tokenRepository->deleteExpired(); 120 | 121 | $this->assertDatabaseCount(config('passwordless.table'), 0); 122 | } 123 | 124 | private function getTokenRepository(): TokenRepository 125 | { 126 | $key = $this->app['config']['app.key']; 127 | 128 | if (Str::startsWith($key, 'base64:')) { 129 | $key = base64_decode(substr($key, 7)); 130 | } 131 | 132 | $config = $this->getPasswordlessConfig(); 133 | 134 | return new TokenRepository( 135 | $this->app['db']->connection(), 136 | $config['table'], 137 | $key, 138 | $config['expire'], 139 | $config['throttle'] 140 | ); 141 | } 142 | 143 | private function getPasswordlessConfig(): array 144 | { 145 | return $this->app['config']->get('passwordless'); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Run Unit Tests](https://github.com/norbybaru/laravel-passwordless-authentication/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/norbybaru/laravel-passwordless-authentication/actions/workflows/run-tests.yml) [![PHPStan](https://github.com/norbybaru/laravel-passwordless-authentication/actions/workflows/phpstan.yml/badge.svg?branch=main)](https://github.com/norbybaru/laravel-passwordless-authentication/actions/workflows/phpstan.yml) [![Laravel Pint](https://github.com/norbybaru/laravel-passwordless-authentication/actions/workflows/pint.yml/badge.svg?branch=main)](https://github.com/norbybaru/laravel-passwordless-authentication/actions/workflows/pint.yml) 2 | 3 | ![PASSWORDLESS-AUTH](./loginlink.png) 4 | # LARAVEL PASSWORDLESS AUTHENTICATION 5 | Laravel Passwordless Authentication using Magic Link. 6 | 7 | This package enables authentication through email links, eliminating the requirement for users to input passwords for authentication. Instead, it leverages the user's email address to send a login link to their inbox. Users can securely authenticate by clicking on this link. It's important to note that the package does not include a user interface for the authentication page; it assumes that the application's login page will be custom-built. Make sure to scaffold your login UI page accordingly to integrate seamlessly with this package. 8 | 9 | **PS. Email provider must be setup correctly and working to email magic link to authenticate user** 10 | 11 | ## Installation 12 | 13 | ```sh 14 | composer require norbybaru/passwordless-auth 15 | ``` 16 | 17 | ## Publishing the config file 18 | ```sh 19 | php artisan vendor:publish --provider="NorbyBaru\Passwordless\PasswordlessServiceProvider" --tag="passwordless-config" 20 | ``` 21 | 22 | ## Preparing the database 23 | Publish the migration to create required table: 24 | ```sh 25 | php artisan vendor:publish --provider="NorbyBaru\Passwordless\PasswordlessServiceProvider" --tag="passwordless-migrations" 26 | ``` 27 | Run migrations. 28 | ```sh 29 | php artisan migrate 30 | ``` 31 | 32 | # Basic Usage 33 | ## Preparing Model 34 | Open the `User::class` Model and ensure to implements `NorbyBaru\Passwordless\CanUsePasswordlessAuthenticatable::class` and to add trait `NorbyBaru\Passwordless\Traits\PasswordlessAuthenticatable::class` to the class 35 | 36 | ```php 37 | 'dashboard', 62 | ``` 63 | 64 | - Update `login_route` to the correct route name of your login page to allow redirecting user 65 | back to that page on invalid magic link. 66 | eg. 67 | ``` 68 | 'login_route' => 'auth.login', 69 | ``` 70 | 71 | ## Setup Login Routes 72 | Update application Login routes to sen Magic Link to user 73 | 74 | ```php 75 | validate([ 81 | 'email' => 'required|email|exists:users', 82 | ]); 83 | 84 | $status = Passwordless::magicLink()->sendLink($validated); 85 | 86 | return redirect()->back()->with([ 87 | 'status' => trans($message) 88 | ]); 89 | }); 90 | 91 | ``` 92 | 93 | ## Setup Mail Provider 94 | Make sure to have your application mail provider setup and working 100% for your Laravel application 95 | ``` 96 | MAIL_MAILER= 97 | MAIL_HOST= 98 | MAIL_PORT= 99 | MAIL_USERNAME= 100 | MAIL_PASSWORD= 101 | MAIL_ENCRYPTION=tls 102 | MAIL_FROM_ADDRESS= 103 | MAIL_FROM_NAME="${APP_NAME}" 104 | ``` 105 | 106 | ## Setup Translations 107 | Add file `passwordless.php` in your translations directory and copy the entry below. 108 | Feel free to update text to suit your application needs. 109 | 110 | ```php 111 | return [ 112 | 'sent' => 'Login link sent to inbox.', 113 | 'throttled' => 'Login link was already sent. Please check your inbox or try again later.', 114 | 'invalid_token' => 'Invalid link supplied. Please request new one.', 115 | 'invalid_user' => 'Invalid user info supplied.', 116 | 'verified' => 'Login successful.', 117 | ]; 118 | ``` 119 | 120 | # Advance Usage 121 | ## Override MagicLinkNotification 122 | 123 | To override default notification template, override method `sendAuthenticationMagicLink` in your User model which implements interface `CanUsePasswordlessAuthenticatable` 124 | 125 | ```php 126 | public function sendAuthenticationMagicLink(string $token): void 127 | { 128 | // Replace with your notification class. 129 | 130 | // eg. $this->notify(new SendMagicLinkNotification($token)); 131 | } 132 | ``` 133 | 134 | ## Run Unit Test 135 | ```sh 136 | composer test 137 | ``` 138 | 139 | ## Run Code Formatter 140 | ```sh 141 | composer fmt 142 | ``` --------------------------------------------------------------------------------